All files / web/src/app/api/worksheets/download/[id] route.ts

0% Statements 0/154
0% Branches 0/1
0% Functions 0/1
0% Lines 0/154

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155                                                                                                                                                                                                                                                                                                                     
/**
 * GET /api/worksheets/download/[id]
 *
 * Download a PDF for a shared worksheet
 * Generates PDF on-demand from stored config
 */

import { eq } from 'drizzle-orm'
import { execSync } from 'child_process'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { worksheetShares } from '@/db/schema'
import { isValidShareId } from '@/lib/generateShareId'
import { parseAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { validateWorksheetConfig } from '@/app/create/worksheets/validation'
import {
  generateProblems,
  generateSubtractionProblems,
  generateMixedProblems,
} from '@/app/create/worksheets/problemGenerator'
import { generateTypstSource } from '@/app/create/worksheets/typstGenerator'
import type { WorksheetProblem, WorksheetFormState } from '@/app/create/worksheets/types'
import { withAuth } from '@/lib/auth/withAuth'

export const GET = withAuth(async (request, { params }) => {
  try {
    const { id } = (await params) as { id: string }

    // Validate ID format
    if (!isValidShareId(id)) {
      return NextResponse.json({ error: 'Invalid share ID format' }, { status: 400 })
    }

    // Fetch share record
    const share = await db.query.worksheetShares.findFirst({
      where: eq(worksheetShares.id, id),
    })

    if (!share) {
      return NextResponse.json({ error: 'Share not found' }, { status: 404 })
    }

    // Parse and validate config (auto-migrates to latest version)
    const parsedConfig = parseAdditionConfig(share.config)

    // Validate configuration
    // Cast to WorksheetFormState which is a permissive union type during form editing
    // The parsed V4 config is compatible but TypeScript needs help with the union types
    const validation = validateWorksheetConfig(parsedConfig as unknown as WorksheetFormState)
    if (!validation.isValid || !validation.config) {
      return NextResponse.json(
        { error: 'Invalid worksheet configuration', errors: validation.errors },
        { status: 400 }
      )
    }

    const config = validation.config

    // Generate problems based on operator type
    let problems: WorksheetProblem[]
    if (config.operator === 'addition') {
      problems = generateProblems(
        config.total,
        config.pAnyStart,
        config.pAllStart,
        config.interpolate,
        config.seed,
        config.digitRange
      )
    } else if (config.operator === 'subtraction') {
      problems = generateSubtractionProblems(
        config.total,
        config.digitRange,
        config.pAnyStart,
        config.pAllStart,
        config.interpolate,
        config.seed
      )
    } else {
      // mixed
      problems = generateMixedProblems(
        config.total,
        config.digitRange,
        config.pAnyStart,
        config.pAllStart,
        config.interpolate,
        config.seed
      )
    }

    // Build share URL for QR code if enabled
    let shareUrl: string | undefined
    if (config.includeQRCode) {
      const protocol = request.headers.get('x-forwarded-proto') || 'https'
      const host = request.headers.get('host') || 'abaci.one'
      shareUrl = `${protocol}://${host}/worksheets/shared/${id}`
    }

    // Generate Typst sources (one per page)
    const typstSources = await generateTypstSource(config, problems, shareUrl)

    // Join pages with pagebreak for PDF
    const typstSource = typstSources.join('\n\n#pagebreak()\n\n')

    // Compile with Typst: stdin → stdout
    let pdfBuffer: Buffer
    try {
      pdfBuffer = execSync('typst compile --format pdf - -', {
        input: typstSource,
        maxBuffer: 10 * 1024 * 1024, // 10MB limit
      })
    } catch (error) {
      console.error('Typst compilation error:', error)

      const stderr =
        error instanceof Error && 'stderr' in error
          ? String((error as any).stderr)
          : 'Unknown compilation error'

      return NextResponse.json(
        {
          error: 'Failed to compile worksheet PDF',
          details: stderr,
        },
        { status: 500 }
      )
    }

    // Generate filename from title or share ID
    const filename = share.title
      ? `worksheet-${share.title.replace(/[^a-zA-Z0-9]/g, '-')}.pdf`
      : `worksheet-${id}.pdf`

    // Return binary PDF directly
    return new Response(pdfBuffer as unknown as BodyInit, {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="${filename}"`,
      },
    })
  } catch (error) {
    console.error('Error downloading worksheet:', error)

    const errorMessage = error instanceof Error ? error.message : String(error)

    return NextResponse.json(
      {
        error: 'Failed to download worksheet',
        message: errorMessage,
      },
      { status: 500 }
    )
  }
})